Arduino ist da, um schnell einen Einstieg zu haben. Ohne Studium, ohne Ausbildung, ohne Vorkenntnisse. Vieles ist schnell im Internet zusammenkopiert, einiges versteht man, das andere nimmt man als gegeben hin. Daraus baut man seinen Wissen langsam auf, vieles wird auch noch nach Jahren gleich gemacht, obwohl es viel einfachere Möglichkeiten gibt. Ich möchte hier ein paar Basics aus allen Schichten aufgreifen, die in vielen Programmiersprachen ebenfalls so oder ähnlich sind.
Übersicht:
- i++
- Exkurs: i++/++i
- Ternärer Operator
- Modulo
- Exkurs: Modulo für Ziffern in z.B. Datumsanzeigen
- Exkurs: Modulo statt for-Schleife mit delay
- Bitoperationen
- Exkurs: Datenspeicherung als Muster
- Datentypen
- RAM/Flash
- Volatile
i++
Besonders häufig sehe ich Konstrukte, die grundlegend okay, aber eigentlich unnötig sind. Ein Beispiel mit vielen Verbesserungsmöglichkeiten:
long i = 0;
double j;
void interruptHandler {
i = i+1;
if (i == 8) {
i = 0;
}
j = i / 2;
}
Zu allererst, i = i+1;
kann man eliminieren. Man kann vor das Gleichzeichen einen der Operatoren * / + - << >> | & ^ %
setzen, sodass in diesem Fall i += 1;
angebracht wäre. Es gilt i = i o j → i o= j
Allerdings geht das beim De- oder Inkrementieren um 1 noch leichter. Mit der Schreibweise i++;
passiert selbiges mit deutlich weniger Zeichen.
Ternärer Operator
Der ternäre Operator (ternär = drei Grundeinheiten) ist ein dreiteiliger Ausdruck, der die if-Abfragen auch zum Einzeiler reduzieren können. i += i < 8 ? 1 : -8;
entspricht einer Funktion, die einen Integer zurück gibt (kann auch String, double oder anderes sein), basierend auf dem vorangestellten Ausdruck:
int ternary() {
if (i < 8) {
return 1;
} else {
return -8;
}
}
/*************/
if (i < 8) {
i += 1;
} else {
i += -8;
}
Ist jetzt hier nicht unbedingt das beste Beispiel, aber als Erklärung genügt es.
Modulo
Rechnen mit Rest ist Grundschul-Spielkram. Komisch, ich benutze sie jeden Tag. Denn der sogenannte Modulo gibt immer den Rest der Division zurück und ist in vielerlei Hinsicht hilfreich. Auch bei obigem Beispiel, denn so viele Zeichen und Logik, wie beim ternären Operator sind gar nicht nötig: i %= 8;
. Was passiert hier? Simple Mathematik, in vielen Augen aber Hokus-Pokus. Für die Zahlen 0-7 bleibt alles beim Alten, 0 durch 8 geteilt ist bekanntlich 0, 6 durch 8 sind 0,75 oder bei der Division mit Rest 0 Rest 6. Da der Rest in die Variable geschrieben wird, bleiben hier die 6 stehen. Bei 8 kippt es allerdings: 8/8 = 1 Rest 0, also i = 0
.
Bitoperatoren
Dass ich nun ausgerechnet 8 im Beispiel gewählt habe, kommt nicht von ungefähr. Mit ein Lieblingswerkzeug von mir sind Bit-Operationen. Man betrachtet die Zahlen nicht mehr als Dezimalzahl, sondern als Binärzahl und führt darauf Modifikationen aus. Modulo und Bit-Operation sind in diesem Fall gleichviel Schreibaufwand für den Programmierer, die Bit-Operation nimmt allerdings deutlich weniger Zyklen in Anspruch. Mag bei 16 MHz und simplen Programmen nicht schwer in’s Gewicht fallen, kann einem später aber bei komplexen, Laufzeitintensiven Programmen zum Verhängnis werden.
i &= 7
agiert ähnlich, wie i %= 8
, dahinter steckt allerdings eine andere Macht. Nehmen wir an, i wäre aktuell 3, also 0b11:
0b00011 //3
& 0b111 //7
= 0b011 //3
Die Alternative mit i = 12, also 0b1010:
0b01100 //12
& 0b111 //7
= 0b100 //4
Die Werte sind identisch zu % 8
. Es wird durch das Muster 0b111 sozusagen in eine leere Schale gesiebt, eine 1 stellt ein Loch dar, wo ein Bit durchfallen kann, eine 0 ist geschlossen, dort kann kein Bit passieren. Zwischen 0b (definition der Zahl durch Bitfolge, statt Dezimalzahl) und der ersten 1 stehen unendlich (eigentlich nur max. so viele, wie der Datentyp groß ist, für dieses Beispiel aber egal) nullen. Die erste 1 prallt also ab, danach folgt eine 0, fällt durch das Sieb. Die 1 und 0 fallen ebenfalls durch.
Anders läuft es bei | (oder), hier werden die beiden Zahlen ohne Sieb übereinander gelegt. Nur Bits, die bei beiden null sind, bleiben null.
Das Gegenteil bewirkt ^ (exklusives oder). Nur unterschiedliche Bits werden hier übernommen, also 0^0 = 0, 1^1 = 0, 1^0=1, 0^1=1.
Die Variable j
bekommt i/2
zugewiesen. In weniger Zyklen geht es auch, indem wir das niedrigste Bit löschen und die ganze Zahl nach rechts rutscht. j = i >> 1
macht aus 0b100 (4) 0b10 (2), also durch zwei geteilt. Dieses lässt sich mit jeglichen Zweierpotenzen machen: x/2n = x >> n
. Auch in die andere Richtung geht es mittels <<
.
Datentypen
Im Beispiel sind allerdings auch noch weitere Fehler.
Zum einen: j
ist als double
, also Dezimalzahl, wird aber nie Nachkommastellen besitzen, die von .0 abweichen. Also statt einer reellen Zahl werden wir immer eine natürliche Zahl bekommen. Das ist dem geschuldet, dass wir durch die natürliche Zahl 2 teilen. Geben wir diese hingegen als 2.0 an, so wird j
auch etwa 1.5 annehmen. Je nach Kompiler wird dieses aber auch ignoriert. Double ist bei allen 8-Bit AVRs in Arduino jedoch identisch mit float, welche mit 5-6 Bit Genauigkeit arbeitet. Würde in unserem Fall ausreichen, bei anderen Anwendungen ärgert man sich schnell darüber. Besonders, weil teilweise Ergebnisse heraus kommen, die mathematisch nicht stimmen (etwa 6.0/3.0=1.9999). Beim Due (und wohl anderen 32-Bit Arduinos) werden doubles als 64bit große Zahl abgelegt, haben dann bis zu 15 Bit Genauigkeit.
Auch wird der Speicher teilweise eng, da sollte man sich Gedanken über die Datentypen machen, die man einsetzt. long ist in diesem Fall komplett überdimensioniert, da wir nur Werte zwischen 0 und 7 haben, nie jedoch zwei Milliarden, geschweige etwas negatives. Dass man den negativen Wertebereich nicht benötigt drückt man durch unsigned datentyp
aus. Wir benutzen nur 4 Byte, einen Datentyp in der Größe gibt es nicht, also nehmen wir den nächst größeren. char
ist meist 1 Byte groß, kann jedoch auch bis zu 4 Byte in Anspruch nehmen, um etwa UTF-8 zu unterstützen. Um das Dilemma nicht zu haben, gibt es bei Arduino sogenannte typedefs, die einen Datentyp-Alias erstellen:
Datentyp | Minimum | Maximum |
boolean | 0 | 1 |
int8_t, char | -128 | 127 |
uint8_t, unsigned char | 0 | 255 |
int16_t, short, int | -32768 | 32767 |
uint16_t, unsigned short, unsigned int | 0 | 65535 |
int32_t, long, teilweise auch int | -2147483648 | 2147483647 |
uint32_t, unsigned long, teilweise auch unsigned int | 0 | 4294967295 |
RAM/Flash
Apropos Speicher:
Einige Programme stürzen ab, weil der RAM voll ist. Besonders bei Strings füllt sich der RAM schnell, hier ist es von Vorteil, wenn man die Texte im Flash mittels
#include <avr/pgmspace.h>
const datentyp name PROGMEM = …
ablegt. Die Werte sind durch const
dann nicht mehr veränderbar, um Strings für Funktionen wieder verwertbar zu machen, ist statt nur der Variable allein das Konstrukt F(name)
vonnöten.
Volatile
Da wir es hier symbolisch mit einem Interrupt zu tun haben, kann es vorkommen, dass im Hauptprogramm manchmal veraltete Werte ausgeworfen werden. Der Prozessor hat die Variable bereits in ein Register geladen, dann wird sie durch den Interrupt verändert, im weiteren Programm wird die Variable wieder verwertet. Sofern das Register noch nicht anderweitig überschrieben wurde, so denkt der Prozessor, dass die Variable darin weiterhin bestand hat. Mit dem Schlüsselwort volatile
vor der Variablendeklaration wird klar gemacht, dass diese Variable jedes mal neu geladen werden sollen. Egal, ob das Register unverändert geblieben ist (push/pop, etwa durch Funktionsaufrufe, von dem man in C eh nichts mitbekommt gilt als unverändert), es wird überschrieben.
Hallo Luca, ein sehr interessanter Beitrag über die C-Programmierung von Dir! Hatte gestern Abend endlich mal wieder etwas Zeit dafür. Bin schon gespannt auf die Fortsetzung.
Gruß Olaf